feat(mail): send idempotency + delivery worker (HT-16) - #21
Conversation
Adds a caller-supplied idempotency key to the reply send path plus a plain delivery-worker sweep function, closing the "idempotency is NOT yet handled here (HT-16)" TODO in src/mail/send.ts. - Migration 003: threads.idempotency_key/send_envelope/claimed_until, outbound-only, with a partial unique index for atomic get-or-insert. - Store: appendThread's INSERT...ON CONFLICT...DO NOTHING get-or-insert (same transaction as its existing FOR UPDATE lock), plus claimThreadForDelivery/ releaseThreadLease/listDeliverableThreads for the delivery lease. - send.ts: sendReply branches on delivery_status for a keyed retry (replay success, claim-then-resend, or retry-in-progress); attemptDeliveryOfClaimedThread is the shared helper the new delivery-worker.ts sweep also calls. The no-key path is untouched — all 7 pre-existing send.test.ts tests pass unedited. - API: POST .../replies now requires an Idempotency-Key header (400 if missing); retry-in-progress maps to 409 retry_in_progress. Breaking change, dogfood-only endpoint. - specs/mail/sending.md and specs/api/agent-inbox-v1.md updated to close their HT-16 forward references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughThe change adds conversation-scoped reply idempotency, persisted outbound envelopes, delivery leases, retry sweeping, migration support, API validation, replay handling, and related specifications and tests. ChangesIdempotent outbound delivery
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant handleReply
participant sendReply
participant ConversationStore
participant EmailSender
Client->>handleReply: POST reply with Idempotency-Key
handleReply->>sendReply: send keyed reply
sendReply->>ConversationStore: get-or-insert thread and claim lease
ConversationStore-->>sendReply: stored thread or retry-in-progress
sendReply->>EmailSender: send stored envelope
EmailSender-->>sendReply: delivery result
sendReply->>ConversationStore: release lease and persist status
sendReply-->>handleReply: result
handleReply-->>Client: HTTP response
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
src/db/migrate.ts (1)
105-174: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
claimed_untilisn't actually constrained to outbound-only, despite the doc comment's claim.The doc comment says all three new columns are outbound-only, but only
idempotency_keyandsend_envelopeget a CHECK constraint enforcing that;claimed_untilhas none. Today this is only enforced via the app-leveldirection = 'outbound'scoping inclaimThreadForDelivery/releaseThreadLease— a schema-level gap relative to the other two columns and the stated invariant.🛡️ Proposed fix
ALTER TABLE threads ADD CONSTRAINT threads_send_envelope_outbound_only CHECK ( (direction = 'outbound') OR (send_envelope IS NULL) ); +ALTER TABLE threads ADD CONSTRAINT threads_claimed_until_outbound_only CHECK ( + (direction = 'outbound') OR (claimed_until IS NULL) +); CREATE UNIQUE INDEX threads_conversation_idempotency_key_idx ON threads (conversation_id, idempotency_key) WHERE idempotency_key IS NOT NULL;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/db/migrate.ts` around lines 105 - 174, Update the MIGRATION_003_SEND_IDEMPOTENCY schema to add an outbound-only CHECK constraint for claimed_until, matching the existing threads_idempotency_key_outbound_only and threads_send_envelope_outbound_only constraints: inbound rows must have claimed_until NULL, while outbound rows may contain either value.src/store/conversations.test.ts (1)
544-574: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy liftThis "concurrent" test doesn't exercise real DB-level concurrency.
PGlite runs in Postgres single-user mode with one exclusive connection, so
Promise.all-ing twoappendThreadcalls against the sameDbinstance still serializes them at the wire-protocol level — this test proves the second sequential call correctly finds the first's committed row, not that theFOR UPDATElock +ON CONFLICTcombination is race-safe under genuinely overlapping transactions (e.g. two processes, or two real connections). The comment at line 570 ("Exactly one of the two calls actually created the row") reads as if this validates the race, but PGlite's single-connection model can't produce that race in the first place.Consider either softening the test's framing (it validates sequential get-or-insert correctness, not concurrency safety) or, if true concurrency coverage is wanted, exercising it against a real multi-connection Postgres in CI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/store/conversations.test.ts` around lines 544 - 574, Reframe the test named “concurrent appendThread calls with the SAME key resolve to exactly one created row” as sequential or single-connection get-or-insert coverage, since Promise.all on the shared PGlite store does not create real database concurrency. Update its description and the “Exactly one...” assertion comment to avoid claiming race-safety; only introduce multi-connection Postgres coverage if genuine concurrency testing is required.src/mail/delivery-worker.ts (1)
100-116: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffSequential per-candidate processing caps sweep throughput.
Each candidate is claimed and sent fully serially. Since each candidate has its own independent lease, these could be processed with bounded concurrency (e.g. a small worker pool) to reduce total sweep latency when
batchSizecandidates are all genuinely eligible. Given the documented intent of keeping this a simple, low-risk sweep function, this is a nice-to-have rather than a blocker.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mail/delivery-worker.ts` around lines 100 - 116, Update the candidate-processing loop in the delivery sweep to process independent claims and delivery attempts with bounded concurrency rather than fully serial execution. Preserve the existing claim, sent, failed, and skipped accounting, and keep the concurrency limit small and explicit so the sweep remains simple and low risk.src/mail/send.ts (1)
251-260: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
thread.messageId as stringbypasses the null-check pattern used elsewhere.The invariant (deliveryStatus
'sent'⇒messageIdnon-null) is presumably schema-enforced, butattemptDeliveryOfClaimedThread(below) uses an explicit runtime check instead of a type assertion for the same invariant. For consistency and to fail loudly instead of silently returning a bogus value if the invariant is ever violated, prefer the same explicit check here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mail/send.ts` around lines 251 - 260, Replace the thread.messageId as string assertion in the sent-status replay branch of attemptDeliveryOfClaimedThread with an explicit runtime null check matching the check used in the delivery attempt path. Fail loudly when messageId is missing, while preserving the existing successful replay response when it is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/api/conversations.ts`:
- Around line 240-244: Normalize the Idempotency-Key once in the conversation
request flow by trimming the header value after retrieval, validate the
normalized value for emptiness, and reuse that normalized key in the sendReply
call. Update both the validation block and sendReply argument so
whitespace-padded representations map to the same downstream key.
In `@src/mail/send.ts`:
- Around line 340-419: Prevent duplicate delivery by ensuring
attemptDeliveryOfClaimedThread does not call sender.send for threads whose
deliveryStatus is already sent. Prefer adding delivery_status IN
('pending','failed') to claimThreadForDelivery, or add an explicit sent-status
guard before the send while preserving the existing result contract.
---
Nitpick comments:
In `@src/db/migrate.ts`:
- Around line 105-174: Update the MIGRATION_003_SEND_IDEMPOTENCY schema to add
an outbound-only CHECK constraint for claimed_until, matching the existing
threads_idempotency_key_outbound_only and threads_send_envelope_outbound_only
constraints: inbound rows must have claimed_until NULL, while outbound rows may
contain either value.
In `@src/mail/delivery-worker.ts`:
- Around line 100-116: Update the candidate-processing loop in the delivery
sweep to process independent claims and delivery attempts with bounded
concurrency rather than fully serial execution. Preserve the existing claim,
sent, failed, and skipped accounting, and keep the concurrency limit small and
explicit so the sweep remains simple and low risk.
In `@src/mail/send.ts`:
- Around line 251-260: Replace the thread.messageId as string assertion in the
sent-status replay branch of attemptDeliveryOfClaimedThread with an explicit
runtime null check matching the check used in the delivery attempt path. Fail
loudly when messageId is missing, while preserving the existing successful
replay response when it is present.
In `@src/store/conversations.test.ts`:
- Around line 544-574: Reframe the test named “concurrent appendThread calls
with the SAME key resolve to exactly one created row” as sequential or
single-connection get-or-insert coverage, since Promise.all on the shared PGlite
store does not create real database concurrency. Update its description and the
“Exactly one...” assertion comment to avoid claiming race-safety; only introduce
multi-connection Postgres coverage if genuine concurrency testing is required.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6ba1a5f5-7de3-4051-b9d1-d58f3dceeaa8
📒 Files selected for processing (12)
specs/api/agent-inbox-v1.mdspecs/mail/sending.mdsrc/api/conversations.tssrc/api/index.test.tssrc/db/migrate.test.tssrc/db/migrate.tssrc/mail/delivery-worker.test.tssrc/mail/delivery-worker.tssrc/mail/send.test.tssrc/mail/send.tssrc/store/conversations.test.tssrc/store/conversations.ts
…ease/latency coupling, key validation Corrects sending.md's at-most-once implication to at-least-once (with the concrete residual: accept-then-mark-fails leaves a stale pending row that gets re-sent), raises DEFAULT_LEASE_MS to 120s with the lease/send-duration invariant spelled out, corrects a misleading "left claimed delays resend" comment, adds real-race caveats to the single-connection PGlite concurrency tests, and validates+trims the Idempotency-Key header (400 on empty-after- trim or >255 chars). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
specs/mail/sending.md (2)
89-100: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winDo not promise byte-identical wire output from a structured snapshot.
send_envelopestores structured fields, and retries reconstructOutboundEmail; raw MIME bytes and provider serialization are not persisted. Narrow this to field/semantic identity, or persist canonical MIME and prove wire equivalence with fixtures.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@specs/mail/sending.md` around lines 89 - 100, Narrow the retry guarantee in the send_envelope specification from byte-identical wire output to preserving the same stored envelope fields and email semantics. Update the affected statements to acknowledge that retries reconstruct OutboundEmail and provider serialization may differ; do not claim wire equivalence unless canonical MIME is persisted and validated.
102-118: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftFence lease release to the claimant.
claimThreadForDeliveryandreleaseThreadLeaseare keyed only bythreadId(src/store/conversations.ts:592-643). If a lease expires before the post-send release, another attempt can claim/send, then the original attempt can clear the new lease or overwrite its status. A send-duration bound does not cover process pauses or delayed database writes. Return a claim token/version and require it for release and status updates.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@specs/mail/sending.md` around lines 102 - 118, Update claimThreadForDelivery and releaseThreadLease to use a claimant token or lease version returned by the claim and required by release and sent/failed status updates. Ensure each mutation only succeeds when the stored token/version still matches the claimant, preventing an expired lease holder from clearing or overwriting a newer claim; propagate this token through both retry and delivery-worker send paths.src/mail/send.ts (2)
260-310: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winKeep the sent-state check atomic with the claim.
The pre-claim
deliveryStatus === 'sent'shortcut does not close the listing/claim race.claimThreadForDeliverycurrently checks only lease availability, so a worker can claim a row that another retry markedsentand resend it. Filterdelivery_status IN ('pending', 'failed')in the atomic claim or reject sent rows beforesender.send(). This remains the previously reported issue.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mail/send.ts` around lines 260 - 310, Make the sent-state guard atomic with delivery claiming: update claimThreadForDelivery so it only claims rows whose delivery_status is pending or failed, preventing a concurrent worker from claiming a row marked sent. Preserve the existing retry-in-progress behavior and ensure attemptDeliveryOfClaimedThread never calls sender.send for a sent row.
430-449: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winExpose the unmarked-success state to the worker.
When
releaseThreadLease(..., 'sent')fails, this function still returnsok: true, whilerunDeliveryWorkercountsresult.okassenteven though the row remainspending. Return the persisted status (or a distinctsent-unmarkedresult), or adjust the worker report contract so monitoring does not claim reconciliation succeeded.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/mail/send.ts` around lines 430 - 449, The attemptDeliveryOfClaimedThread success path must expose when delivery succeeded but releaseThreadLease(..., 'sent') failed instead of returning an undifferentiated ok: true. Update this function and the runDeliveryWorker result/report handling to preserve a distinct sent-unmarked or persisted-status outcome, ensuring monitoring does not count the still-pending row as reconciled sent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@specs/mail/sending.md`:
- Around line 171-182: Resolve the inconsistency between the “Precondition”
heading and the provider deduplication “SHOULD” in this section: either make
Message-ID deduplication a mandatory deployment requirement for at-most-once
delivery, or rename the heading to describe it as a recommendation while
preserving the documented at-least-once behavior when unsupported.
---
Outside diff comments:
In `@specs/mail/sending.md`:
- Around line 89-100: Narrow the retry guarantee in the send_envelope
specification from byte-identical wire output to preserving the same stored
envelope fields and email semantics. Update the affected statements to
acknowledge that retries reconstruct OutboundEmail and provider serialization
may differ; do not claim wire equivalence unless canonical MIME is persisted and
validated.
- Around line 102-118: Update claimThreadForDelivery and releaseThreadLease to
use a claimant token or lease version returned by the claim and required by
release and sent/failed status updates. Ensure each mutation only succeeds when
the stored token/version still matches the claimant, preventing an expired lease
holder from clearing or overwriting a newer claim; propagate this token through
both retry and delivery-worker send paths.
In `@src/mail/send.ts`:
- Around line 260-310: Make the sent-state guard atomic with delivery claiming:
update claimThreadForDelivery so it only claims rows whose delivery_status is
pending or failed, preventing a concurrent worker from claiming a row marked
sent. Preserve the existing retry-in-progress behavior and ensure
attemptDeliveryOfClaimedThread never calls sender.send for a sent row.
- Around line 430-449: The attemptDeliveryOfClaimedThread success path must
expose when delivery succeeded but releaseThreadLease(..., 'sent') failed
instead of returning an undifferentiated ok: true. Update this function and the
runDeliveryWorker result/report handling to preserve a distinct sent-unmarked or
persisted-status outcome, ensuring monitoring does not count the still-pending
row as reconciled sent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8630f422-e1b3-4042-a5ba-9bd9e100ec56
📒 Files selected for processing (8)
specs/api/agent-inbox-v1.mdspecs/mail/sending.mdsrc/api/conversations.tssrc/api/index.test.tssrc/mail/delivery-worker.test.tssrc/mail/send.test.tssrc/mail/send.tssrc/store/conversations.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- src/api/conversations.ts
- src/mail/delivery-worker.test.ts
- src/store/conversations.test.ts
- specs/api/agent-inbox-v1.md
- src/mail/send.test.ts
…im double-send (CodeRabbit review)
claimThreadForDelivery's WHERE clause only checked the lease
(claimed_until), not delivery_status. releaseThreadLease clears
claimed_until in the same write that records the outcome, so a row that
reached 'sent' had a free lease and could be reclaimed and re-sent by a
concurrent keyed sendReply retry or the delivery worker. Add `AND
delivery_status IN ('pending', 'failed')` to the claim so a 'sent' row
can never be reclaimed, and teach sendReply's keyed path to re-read the
thread on a failed claim so a row found already 'sent' resolves to the
same success-replay result instead of a misleading 'retry-in-progress'.
Also rewords specs/mail/sending.md §4 to stop calling provider
Message-ID dedup a "precondition" — it's a SHOULD/recommendation; the
engine's at-least-once guarantee holds without it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Disposition of the three CodeRabbit findings on this PR:
Full gates (typecheck, lint, full test suite) all green: 327/327 tests passing. |
Summary
idempotencyKeywith a creation/delivery split: a retry finds the existing outbound row via an atomic get-or-insert and reuses itsmessage_idand storedsend_envelopeverbatim — never re-mints, never recomputes.threads.idempotency_key/send_envelope/claimed_until, all outbound-only) plus a partial unique index on(conversation_id, idempotency_key)are the schema-level mechanism; see the migration's doc comment for the NULL-semantics and backfill reasoning.ConversationStore.appendThreadnow resolves a keyed call asINSERT ... ON CONFLICT ... DO NOTHING RETURNING *, falling back to aSELECTof the existing row on conflict — inside the SAME transaction that already takes theFOR UPDATElock on the conversation row, so a concurrent retry with the same key is serialized rather than racing.claimThreadForDelivery/releaseThreadLeaseare a lease pair (a plain, row-lockedUPDATE ... WHERE claimed_until IS NULL OR claimed_until < now()) shared bysendReply's keyed-retry path and the newrunDeliveryWorkersweep, so at most one attempt is ever sending a given row at a time.src/mail/delivery-worker.tsis a plain invocable sweep function (runDeliveryWorker), not built onQueueProvider/SchedulerProvider— no such adapter exists yet, so wiring a real schedule (Vercel Cron, or a futureSchedulerProvideradapter) is deferred to a one-line call to this function later.POST /api/v1/conversations/{id}/repliesnow requires anIdempotency-Keyheader (400 if missing/empty) — a deliberate breaking change, since this endpoint is dogfood-only. A replay of the same key on the same conversation returns the original outcome without re-diffing the body or re-invoking the sender; a lease held by a concurrent attempt maps to409 retry_in_progress.specs/mail/sending.md§3a andspecs/api/agent-inbox-v1.md§4a updated to close their HT-16 forward references.Mail-semantics equivalence evidence (CHARTER.md invariant #5)
The no-
idempotencyKeysend path is byte-identical to pre-HT-16 behavior — no fixtures needed here since none existed before, but the strongest evidence available: all 7 pre-existing tests insrc/mail/send.test.tspass completely UNEDITED against the newsendReply, along with every pre-existing test insrc/store/conversations.test.ts(only one assertion's shape was widened, from an exacttoEqualto atoMatchObject, to accommodate the newcreated/threadfields onAppendResult— no behavioral change). Threading (thread.ts), parsing (parse.ts), Message-ID minting (reply-token.ts), and all fixture-based tests are untouched and still pass. Full suite: 320/320 tests passing.Verification (all exit 0)
npx tsc --noEmit -p tsconfig.json— clean, no errors.npx biome check .— clean, no errors (after auto-formatting fixes).npx vitest run— 320 passed (320), 17 test files.npx vitest run --coverage— 320/320 passing; overall 94.92% stmts / 90.29% branch / 96.36% funcs / 95.61% lines (no threshold configured invitest.config, so this is informational).Notes / flagged items
Idempotency-Keyis now required onPOST .../replies. Dogfood-only endpoint, no external consumer today.SchedulerProvidercalling it on a real cron/interval) is intentionally out of scope for this increment, per the approved design.ConversationStoregainedlistDeliverableThreads(not explicitly named in the design's store bullet list) — required for the worker's eligibility sweep, andsend_envelopeis now persisted on every outbound send (keyed or not), not only keyed ones — this is what lets the worker retry a pre-existing no-keypending/failedrow too, and is implied by the design's own parenthetical ("the envelope now gets persisted via sendEnvelope on insert").Adversarial review (pre-human-review)
sending.mdimplied at-most-once delivery; the implementation is at-least-once. §3a now states this explicitly, names the concrete residual (provider accepts → mark-sentwrite fails → row stayspendingwith a live envelope → once stale/lease-free, a worker or keyed replay re-sends an already-delivered message — the engine cannot distinguish "crashed before send" from "sent but unmarked"), and §4 elevates providerMessage-IDdedup from an aside to a stated precondition for true at-most-once.DEFAULT_LEASE_MSraised from 30s to 120s; its doc comment (andsending.md§3a/§4) now states the invariant explicitly — the lease MUST strictly exceed the worst-caseEmailSender.send()duration, or a re-claimed retry can race the original call into a genuine concurrent double-send. Checked the Gmail adapter (src/providers/adapters/gmail/sender.ts) per the instruction not to modify it here: it already bounds its HTTP call with an explicitAbortSignal.timeout, default 30 000 ms (configurable viatimeoutMs), comfortably under the new 120s lease — no code change needed, but see the follow-up note below.attemptDeliveryOfClaimedThreadcomment for "sent but mark-sent failed" implied the row staying claimed meaningfully delays a resend. Corrected: the lease is a fraction of the delivery worker's 5-minutestaleAfterMs, and the no-key path never claims at all — the real backstop is providerMessage-IDdedup (per finding A), not the claimed state.migrate.ts's advisory-lock caveat) to the three concurrency tests that run against single-connection PGlite —conversations.test.ts'sPromise.allsame-key test,send.test.ts's in-flight-lease test, anddelivery-worker.test.ts's cross-path race test — noting they prove sequential claim-while-held logic, not true multi-connection atomicity; that coverage waits for a multi-connection backend.Idempotency-Keyis now trimmed before use and rejected with400 validation_failedif empty after trimming or over 255 characters; the trimmed value is what's stored and passed tosendReply. Added tests: a whitespace-padded key (NBSP, since theHeadersimplementation already strips plain HTTP OWS) replays the same send as its trimmed twin (one send only), and a >255-char key is rejected.agent-inbox-v1.md§4a updated.agent-inbox-v1.md§4a now states that a keyed replay after the conversation has been deleted returns404, not the original201— replay-of-original-outcome does not survive a conversation delete (no mail-safety impact; the original send already happened).Follow-up (not in this PR): the Gmail adapter's 30s default timeout is safely under the new 120s lease today, but nothing ties the two together — a future change to either constant could silently violate the invariant B documents. Worth a lint/test assertion or a shared-constant follow-up ticket.
🤖 Generated with Claude Code
Summary by CodeRabbit
Idempotency-Key(trimmed; max 255 chars) including safe replays for success and retry recovery.failed/pendingsends while preventing duplicate in-flight deliveries.400 validation_failed;retry_in_progressnow returns HTTP409 retry_in_progress).502 send_failedbehavior on provider failures.